Skip to content

Conflicts

In the “Toggle” exercise from the previous lesson, we saw how delegating all props can lead to some issues if there are conflicts.

Let's look at a minimal example:

function Checkbox({ label, ...delegated}) {
const id = React.useId();
return (
<>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
type="checkbox"
{...delegated}
/>
</>
);
}

This Checkbox component applies two hardcoded attributes to the <input>: type and id.

Now, suppose the consumer of this component uses it like this:

<Checkbox
label="Do you agree to the terms?"
type="button"
onClick={handleAgreeToTerms}
/>

The type and onClick props aren't specified in the Checkbox component, and so they're collected into the delegated object, and pasted onto the <input>:

// Here's the React element that will be created:
<input
id={id}
type="checkbox"
type="button"
onClick={handleAgreeToTerms}
/>

We've specified two different values for type, and when there are conflicts like this, later values overwrite earlier ones. And so, this input will be a button instead of a checkbox.

Essentially, the consumer has “hacked” our Checkbox component to not render a checkbox!

Let's rewrite our Checkbox component to spread the provided props first:

function Checkbox({ label, ...delegated}) {
const id = React.useId();
return (
<>
<label htmlFor={id}>
{label}
</label>
<input
{...delegated}
id={id}
type="checkbox"
/>
</>
);
}

With this change, the same <Checkbox> element produces a different result:

<input
// Delegated props:
type="button"
onClick={handleAgreeToTerms}
// Built-in attributes:
id={id}
type="checkbox"
/>
// After removing the duplicate `type`, we're left with:
<input
onClick={handleAgreeToTerms}
id={id}
type="checkbox"
/>

Because we've flipped the order, the user-supplied type="button" will now be overwritten by the built-in type="checkbox".

A powerful tool in API design

When we produce React components, we get to decide how much power we want to give consumers. We can choose which properties they're allowed to overwrite, and which ones are mandatory / locked in.

In the example above, I feel pretty strongly that a Checkbox component should always render an <input type="checkbox">, and so I don't want to let consumers overwrite the type attribute.

But this won't always be the case! Sometimes, I do want to let users overwrite the built-in attributes.

For example, suppose I have a component that generates an SVG icon:

function ArrowIcon({ size, ...delegated }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
>
<path
d="M 20 0 L 24 12 L 0 12 L 24 12 L 20 24"
stroke="black"
strokeLinecap="round"
{...delegated}
/>
</svg>
);
}

By default, this component will render a black arrow with rounded lines, but I can supply my own overrides:

<ArrowIcon stroke="red" strokeLinecap="square" />

There's no right/wrong answer when it comes to where the {...delegated} should go. Rather, it's a choice we can use as a tool, to decide how much power/flexibility I want to grant to the developers consuming this component.

Manually managing conflicts

Sometimes, delegated props is too blunt of a tool, and we need to do some manual work to resolve the conflict.

For example, when it comes to CSS classes, we often want to apply both the user-supplied class as well as the built-in one.

In the Toggle exercise, we manually merged the two classes together so that we were applying the toggle class (which provided all of the standard toggle styling) as well as the green-toggle class (a user-specified class with an override for the toggle's color).

I've built a lot of components that follow this exact template. Here's a minimum viable example, with all the other stuff stripped away:

function Template({ className = '' }) {
const appliedClass = `built-in-class ${className}`;
return (
<div
className={appliedClass}
/>
);
}

In a sense, we've actually seen an example of this pattern already, when we talked about the Rules of Hooks:

function TextInput({ id, label, type }) {
let generatedId = React.useId();
let appliedId = id || generatedId;
return (
<div className="text-input">
<label htmlFor={appliedId}>
{label}
</label>
<input
id={appliedId}
type={type}
/>
</div>
);
}

If the user supplies an id prop, it will be used for the input's id, and the label's htmlFor. If they don't, we'll use the generated value we get from the React.useId hook.

We could rely on rest/spread to apply the correct id on the <input>, but we also need to set the exact same value on the <label>, via the htmlFor attribute. As a result, we need to manage this conflict manually.

Here's one more example, where we can supply custom inline styles to a component that already has some:

function ExampleComponent({
// User-specified styles.
// Defaults to an empty object so that we always receive an
// object, never “undefined”:
style = {},
children,
...delegated
}) {
const builtInStyle = {
padding: 16,
background: 'red',
};
return (
<div
{...delegated}
style={{
// Merge both sets of styles, prioritizing the
// built-in styles:
...style,
...builtInStyle,
}}
>
{children}
</div>
);
}

To review, we have several options when it comes to conflicting attributes.

  1. If we want to allow the consumer to overwrite a particular hardcoded attribute, we can place the {...delegated} syntax afterwards.
  2. If we want to prioritize the hardcoded attribute, however, the {...delegated} syntax should come first.
  3. If we want to merge both values, we'll need to manage it ourselves, without using {...delegated}.

All 3 of these options are valid in different situations. It all comes down to how much control we want to grant the consumer.